D:\a\cssh-rs\cssh-rs\xtask\src\typography.rs
Line | Count | Source |
1 | | //! Typography linter that blocks decorative or "smart" Unicode |
2 | | //! punctuation from sneaking into the repository. |
3 | | //! |
4 | | //! Agents tend to introduce em-dashes, en-dashes, smart quotes, |
5 | | //! ellipsis, arrows, and similar non-ASCII glyphs in comments and |
6 | | //! prose. They look similar to their ASCII equivalents but are not |
7 | | //! what a Windows developer types and not what `cargo fmt` produces. |
8 | | //! |
9 | | //! [`check_typography`] enumerates tracked text files via |
10 | | //! `git ls-files`, scans each for a curated blocklist of code points, |
11 | | //! prints any violations as `path:line:col U+XXXX 'glyph'`, and |
12 | | //! returns an error when at least one violation is found so the |
13 | | //! pre-commit hook and CI both abort. |
14 | | //! |
15 | | //! Performance: the scan runs inside the pre-commit hook, so the |
16 | | //! hot path reads bytes, exits early on pure-ASCII input, and only |
17 | | //! decodes UTF-8 for files that actually contain non-ASCII bytes. |
18 | | |
19 | | use std::path::{Path, PathBuf}; |
20 | | |
21 | | use anyhow::{bail, Context, Result}; |
22 | | |
23 | | /// File extensions whose contents are scanned. |
24 | | /// |
25 | | /// All matching is done in lowercase. Files with no extension are |
26 | | /// scanned only when their path matches [`SCAN_EXTRA_PATHS`]. |
27 | | const SCAN_EXTENSIONS: &[&str] = &[ |
28 | | "rs", "md", "toml", "yml", "yaml", "json", "html", "txt", "cfg", "sh", "ps1", "js", "mjs", |
29 | | ]; |
30 | | |
31 | | /// Tracked paths without a recognised extension that should still be |
32 | | /// scanned (shell scripts, hooks, etc.). Compared against the |
33 | | /// `git ls-files` output verbatim (forward slashes). |
34 | | const SCAN_EXTRA_PATHS: &[&str] = &[".githooks/pre-commit"]; |
35 | | |
36 | | /// Tracked paths that are explicitly excluded from scanning. Used for: |
37 | | /// |
38 | | /// - generated artefacts such as `Cargo.lock`, |
39 | | /// - files (such as the `CHANGELOG.md`) that may legitimately preserve |
40 | | /// historical typography from prior releases, |
41 | | /// - templates and workflow snippets whose non-ASCII content is |
42 | | /// intentional and rendered to users (e.g. social-preview titles, |
43 | | /// GitHub Pages footers, PR-comment heredocs). |
44 | | /// |
45 | | /// Keep this list short -- the goal is to fix offending content, not |
46 | | /// to allowlist around it. Compared against the `git ls-files` output |
47 | | /// verbatim (forward slashes). |
48 | | const ALLOWED_PATHS: &[&str] = &[ |
49 | | "Cargo.lock", |
50 | | ".github/workflows/news-fragment-check.yml", |
51 | | "templates/github-pages-index.html", |
52 | | "templates/social-preview.html", |
53 | | ]; |
54 | | |
55 | | /// Hard cap on file size accepted by the scanner. Anything larger is |
56 | | /// skipped with a warning -- the repo has nothing close to this size, |
57 | | /// and a pathological large file should not block a commit. |
58 | | const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024; |
59 | | |
60 | | /// All side-effecting operations performed by the typography scanner. |
61 | | /// |
62 | | /// Implement with mocks in tests to achieve zero filesystem and |
63 | | /// process side-effects. |
64 | | pub trait TypographySystem { |
65 | | /// Return the list of tracked files reported by `git ls-files`. |
66 | | /// |
67 | | /// Paths are returned with forward slashes (the format `git` |
68 | | /// emits on every platform). |
69 | | /// |
70 | | /// # Errors |
71 | | /// |
72 | | /// Returns an error if the `git` process cannot be started or |
73 | | /// exits non-zero. |
74 | | fn list_tracked_files(&self) -> Result<Vec<String>>; |
75 | | |
76 | | /// Return the size in bytes of the file at `path`. |
77 | | /// |
78 | | /// # Errors |
79 | | /// |
80 | | /// Returns an error if the file cannot be stat-ed. |
81 | | fn file_size(&self, path: &Path) -> Result<u64>; |
82 | | |
83 | | /// Read the full contents of the file at `path` as raw bytes. |
84 | | /// |
85 | | /// # Errors |
86 | | /// |
87 | | /// Returns an error if the file cannot be read. |
88 | | fn read_file(&self, path: &Path) -> Result<Vec<u8>>; |
89 | | } |
90 | | |
91 | | /// Production implementation of [`TypographySystem`]. |
92 | | pub struct RealSystem; |
93 | | |
94 | | #[cfg_attr(coverage_nightly, coverage(off))] |
95 | | impl TypographySystem for RealSystem { |
96 | | fn list_tracked_files(&self) -> Result<Vec<String>> { |
97 | | let output = std::process::Command::new("git") |
98 | | .args(["ls-files"]) |
99 | | .output() |
100 | | .context("failed to run `git ls-files`")?; |
101 | | if !output.status.success() { |
102 | | bail!( |
103 | | "`git ls-files` exited non-zero: {}", |
104 | | String::from_utf8_lossy(&output.stderr) |
105 | | ); |
106 | | } |
107 | | let stdout = |
108 | | String::from_utf8(output.stdout).context("`git ls-files` produced non-UTF-8 output")?; |
109 | | Ok(stdout |
110 | | .lines() |
111 | | .filter(|line| !line.is_empty()) |
112 | | .map(|line| line.to_owned()) |
113 | | .collect()) |
114 | | } |
115 | | |
116 | | fn file_size(&self, path: &Path) -> Result<u64> { |
117 | | let meta = std::fs::metadata(path) |
118 | | .with_context(|| format!("failed to stat {}", path.display()))?; |
119 | | Ok(meta.len()) |
120 | | } |
121 | | |
122 | | fn read_file(&self, path: &Path) -> Result<Vec<u8>> { |
123 | | std::fs::read(path).with_context(|| format!("failed to read {}", path.display())) |
124 | | } |
125 | | } |
126 | | |
127 | | /// A single offending code point found in a scanned file. |
128 | | #[derive(Debug, Clone, PartialEq, Eq)] |
129 | | pub struct Violation { |
130 | | /// Repository-relative path with forward slashes. |
131 | | pub path: String, |
132 | | /// 1-based line number of the offending character. |
133 | | pub line: u32, |
134 | | /// 1-based column (counted in `char`s, not bytes) of the offending |
135 | | /// character. |
136 | | pub column: u32, |
137 | | /// The offending Unicode scalar value. |
138 | | pub character: char, |
139 | | } |
140 | | |
141 | | /// Return `true` when `c` should be flagged by the scanner. |
142 | | /// |
143 | | /// The blocklist is hand-curated to cover the decorative glyphs that |
144 | | /// LLMs habitually substitute for ASCII punctuation. Emoji and other |
145 | | /// non-ASCII characters are deliberately not included. |
146 | | /// |
147 | | /// # Arguments |
148 | | /// |
149 | | /// * `c` - Character to test. |
150 | | /// |
151 | | /// # Returns |
152 | | /// |
153 | | /// `true` when `c` is on the blocklist, `false` otherwise. |
154 | 202 | pub fn is_blocklisted(c: char) -> bool { |
155 | 202 | let cp = c as u32; |
156 | 186 | matches!( |
157 | 202 | cp, |
158 | | // Non-breaking and middle-dot, multiplication, division. |
159 | | 0x00A0 | 0x00B7 | 0x00D7 | 0x00F7 |
160 | | // Exotic spaces. |
161 | 18 | | 0x2000..=0x200B |
162 | | | 0x202F | 0x205F | 0x3000 |
163 | | // Hyphens, en/em-dashes, horizontal bar, minus sign. |
164 | 18 | | 0x2010..=0x2015 | 0x2212 |
165 | | // Smart single and double quotes. |
166 | 13 | | 0x2018..=0x201F |
167 | | // Bullet, ellipsis. |
168 | | | 0x2022 | 0x2026 |
169 | | // Arrows block in its entirety. |
170 | 9 | | 0x2190..=0x21FF |
171 | | // Math comparison glyphs. |
172 | | | 0x2248 | 0x2260 | 0x2264 | 0x2265 |
173 | | ) |
174 | 202 | } |
175 | | |
176 | | /// Decide whether `path` should be scanned. |
177 | | /// |
178 | | /// A file is scanned when: |
179 | | /// |
180 | | /// 1. it is not in [`ALLOWED_PATHS`], and |
181 | | /// 2. its lowercase extension is in [`SCAN_EXTENSIONS`], or its path |
182 | | /// appears verbatim in [`SCAN_EXTRA_PATHS`]. |
183 | | /// |
184 | | /// # Arguments |
185 | | /// |
186 | | /// * `path` - Forward-slash relative path as emitted by |
187 | | /// `git ls-files`. |
188 | | /// |
189 | | /// # Returns |
190 | | /// |
191 | | /// `true` when the file should be scanned, `false` otherwise. |
192 | 21 | pub fn should_scan(path: &str) -> bool { |
193 | 21 | if ALLOWED_PATHS.contains(&path) { |
194 | 5 | return false; |
195 | 16 | } |
196 | 16 | if SCAN_EXTRA_PATHS.contains(&path) { |
197 | 1 | return true; |
198 | 15 | } |
199 | 15 | let Some(dot14 ) = path.rfind('.') else { |
200 | 1 | return false; |
201 | | }; |
202 | 14 | let ext = &path[dot + 1..]; |
203 | 14 | SCAN_EXTENSIONS |
204 | 14 | .iter() |
205 | 73 | .any14 (|allowed| allowed.eq_ignore_ascii_case(ext)) |
206 | 21 | } |
207 | | |
208 | | /// Scan a single file's contents and return any violations. |
209 | | /// |
210 | | /// Pure function -- no I/O. Files that are pure ASCII return early |
211 | | /// before allocating or decoding UTF-8, which keeps the common case |
212 | | /// (almost every `.rs` file in this repo) cheap. |
213 | | /// |
214 | | /// Files that are not valid UTF-8 are reported via the returned |
215 | | /// `non_utf8` flag and produce no violations; the caller decides |
216 | | /// whether to surface that as a warning. |
217 | | /// |
218 | | /// # Arguments |
219 | | /// |
220 | | /// * `path` - Display path used when constructing violations. |
221 | | /// * `bytes` - Raw file contents. |
222 | | /// |
223 | | /// # Returns |
224 | | /// |
225 | | /// `(violations, non_utf8)` where `non_utf8` is `true` if the file |
226 | | /// could not be decoded as UTF-8. |
227 | 10 | pub fn scan_bytes(path: &str, bytes: &[u8]) -> (Vec<Violation>, bool) { |
228 | | // Fast path: pure ASCII -> nothing to flag. |
229 | 112 | if bytes.iter()10 .all10 (|&b| b < 0x80) { |
230 | 3 | return (Vec::new(), false); |
231 | 7 | } |
232 | | |
233 | 7 | let Ok(text5 ) = std::str::from_utf8(bytes) else { |
234 | 2 | return (Vec::new(), true); |
235 | | }; |
236 | | |
237 | 5 | let mut violations = Vec::new(); |
238 | 5 | let mut line: u32 = 1; |
239 | 5 | let mut column: u32 = 1; |
240 | 64 | for c in text5 .chars5 () { |
241 | 64 | if c == '\n' { |
242 | 5 | line += 1; |
243 | 5 | column = 1; |
244 | 5 | continue; |
245 | 59 | } |
246 | 59 | if c == '\r' { |
247 | | // CRLF: do not advance the column. The following '\n' resets it. |
248 | 0 | continue; |
249 | 59 | } |
250 | 59 | if is_blocklisted(c) { |
251 | 5 | violations.push(Violation { |
252 | 5 | path: path.to_owned(), |
253 | 5 | line, |
254 | 5 | column, |
255 | 5 | character: c, |
256 | 5 | }); |
257 | 54 | } |
258 | 59 | column += 1; |
259 | | } |
260 | 5 | (violations, false) |
261 | 10 | } |
262 | | |
263 | | /// Scan every tracked text file and report violations. |
264 | | /// |
265 | | /// Reads the file list via `git ls-files`, filters it through |
266 | | /// [`should_scan`], and runs [`scan_bytes`] on each remaining file. |
267 | | /// Violations are printed to stderr as |
268 | | /// `path:line:col U+XXXX 'glyph'`. |
269 | | /// |
270 | | /// # Arguments |
271 | | /// |
272 | | /// * `system` - Injected I/O provider. |
273 | | /// |
274 | | /// # Returns |
275 | | /// |
276 | | /// `Ok(())` when no violations are found. |
277 | | /// |
278 | | /// # Errors |
279 | | /// |
280 | | /// Returns an error when at least one violation is found, or when an |
281 | | /// I/O operation fails. Files that are too large or not valid UTF-8 |
282 | | /// are skipped with a warning and do not fail the run. |
283 | 4 | pub fn check_typography<S: TypographySystem>(system: &S) -> Result<()> { |
284 | 4 | let files = system.list_tracked_files()?0 ; |
285 | 4 | let mut violations: Vec<Violation> = Vec::new(); |
286 | 5 | for rel in files4 { |
287 | 5 | if !should_scan(&rel) { |
288 | 1 | continue; |
289 | 4 | } |
290 | 4 | let path = PathBuf::from(&rel); |
291 | 4 | let size = system.file_size(&path)?0 ; |
292 | 4 | if size > MAX_FILE_BYTES { |
293 | 1 | log::warn!("skipping {rel}: {size} bytes exceeds {MAX_FILE_BYTES} byte cap"); |
294 | 1 | continue; |
295 | 3 | } |
296 | 3 | let bytes = system.read_file(&path)?0 ; |
297 | 3 | let (mut found, non_utf8) = scan_bytes(&rel, &bytes); |
298 | 3 | if non_utf8 { |
299 | 1 | log::warn!("skipping {rel}: not valid UTF-8"); |
300 | 1 | continue; |
301 | 2 | } |
302 | 2 | violations.append(&mut found); |
303 | | } |
304 | | |
305 | 4 | if violations.is_empty() { |
306 | 3 | log::info!("check-typography: no forbidden Unicode found."); |
307 | 3 | return Ok(()); |
308 | 1 | } |
309 | | |
310 | 1 | let listing = violations |
311 | 1 | .iter() |
312 | 1 | .map(|v| { |
313 | 1 | format!( |
314 | | "{}:{}:{} U+{:04X} {:?}", |
315 | 1 | v.path, v.line, v.column, v.character as u32, v.character |
316 | | ) |
317 | 1 | }) |
318 | 1 | .collect::<Vec<_>>() |
319 | 1 | .join("\n"); |
320 | 1 | log::error!( |
321 | | "check-typography: found {} forbidden Unicode character(s).\n\ |
322 | | Replace them with their ASCII equivalents (em/en-dashes -> '-',\n\ |
323 | | smart quotes -> ' or \", ellipsis -> ..., arrows -> -> / <-, etc.).\n\ |
324 | | \n\ |
325 | | {listing}", |
326 | 1 | violations.len(), |
327 | | ); |
328 | 1 | bail!("found {} forbidden Unicode character(s)", violations.len()) |
329 | 4 | } |
330 | | |
331 | | #[cfg(test)] |
332 | | #[path = "tests/test_typography.rs"] |
333 | | mod tests; |